查看原文
其他

Android无侵入式主题切换揭示动画,仿Telegram/酷安

YenalyLiew 郭霖
2024-07-19


/   今日科技快讯   /

近日,比亚迪发布e平台3.0 Evo,平台包括CTB整车安全架构、十二合一智能电驱、智能宽温域高效热泵、全域智能快充和智能运动控制等五大技术集群。其中,电驱系统提升至十二合一,电机方面实现23000rpm量产最高转速,搭载e平台3.0 Evo的海狮07 EV可实现225km/h的最高车速。

/   作者简介   /

大家周六好,明天短暂休息一天,我们下周一见!

本篇文章来自YenalyLiew的投稿,文章主要分享了如何开发一个主题切换揭示动画效果,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

YenalyLiew的博客地址:
https://juejin.cn/user/4156572929374327/posts

/   前言   /

我之前在用 Telegram 的时候,无意中点击到了设置里的切换夜间按钮,然后被动画惊艳到了。当时我的 Android 技术实在有限,根本无法想象开发者是怎么做到日/夜间切换如此流畅且拥有动画的。我之前确实写过切换主题,但都是立即切换,毫无含金量,调个 recreate() 和 setTheme() 就解决了。当时在我的认知里,切换主题是需要重建(recreate) Activity 的。既然重建,重建之前的 activity 的很多东西几乎都不能保留,所以我想了好久都没想到处理的思路,再加上懒,就索性放弃了。

/   探寻 TG 源码   /

就在前些时候,我心血来潮,想彻底研究研究这是怎么实现的,就往 GitHub 上查了查 Telegram,看看有没有相关源码参考。不看不知道,一看吓一跳,Telegram 的源码竟然是屎山!平常使用的定位源码方式在 Telegram 源码里根本用不了。

然后我只好用 LibChecker 来看一眼那个界面是属于哪个 Activity,然后定点分析。Telegram 的 Activity 并不多,很好找,看样子应该在 LaunchActivity 里,因为其他 Activity 实在和主题、设置不搭边。随即我打开了 ui 文件夹,寻找 LaunchActivity,但令我奇怪的是,文件夹里怎么还有其他几十个 Activity?我随便打开了一个,震惊我一整天。



先别管这么多了,打开 LaunchActivity 找找吧。结果映入我眼帘的前 200 行全是 import,麻了。查关键字,果然找到了关键的字段!

private ImageView themeSwitchImageView;
private View themeSwitchSunView;
private RLottieDrawable themeSwitchSunDrawable;

定位到这些字段所处的位置,就可以开始研究了。代码很长,我就不贴过来占空间了,给个地址,大家可以去观摩下 [源代码],在第 6468 行开始(一共 8000 多行,实在太恐怖)。

具体思路就是:

  1. 代码开始通过从数组中提取参数,设置变量,并为动画准备UI。它获取 drawerLayoutContainer 的宽度和高度,根据 toDark 布尔值设置 darkThemeView 的可见性,并对 drawerLayoutContainer 进行快照(也就是类似截图),以便将其设置给 themeSwitchImageView。
  2. 然后代码根据主题是否切换为暗色来设置 themeSwitchImageView 和 themeSwitchSunView。然后将 themeSwitchImageView 设置为之前的位图快照,并使其可见。代码根据用户点击的位置和 drawerLayoutContainer 的尺寸计算出环形揭示动画的最终半径(计算当前点击的控件距离与应用窗口的欧氏距离,看谁大要哪个)。
  3. 使用 ViewAnimationUtils#createCircularReveal 创建一个 Animator 对象,该对象将在 drawerLayoutContainer 或 themeSwitchImageView 上执行环形揭示动画,具体取决于主题是否切换为暗色。动画的持续时间设置为 400 毫秒,使用 缓动插值器 实现平滑的动画效果。
  4. 最后任务发布到 UI 线程,以在延迟后设置导航栏颜色并检查系统栏颜色。延迟时间是根据主题是否切换为暗色来计算的。最后开始动画。

总结就是:

if (toDark) {
    frameLayout.addView(themeSwitchImageView, 0, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
    themeSwitchSunView.setVisibility(View.GONE);
} else {
    frameLayout.addView(themeSwitchImageView, 1, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
    themeSwitchSunView.setTranslationX(pos[0] - AndroidUtilities.dp(14));
    themeSwitchSunView.setTranslationY(pos[1] - AndroidUtilities.dp(14));
    themeSwitchSunView.setVisibility(View.VISIBLE);
    themeSwitchSunView.invalidate();
}

Animator anim = ViewAnimationUtils.createCircularReveal(toDark ? drawerLayoutContainer : themeSwitchImageView, pos[0], pos[1], toDark ? 0 : finalRadius, toDark ? finalRadius : 0);

切换到夜间前,ImageView 放在根 View (frameLayout)内层(也就是 index = 0, FrameLayout 中 View 的 index 越小越在内层),设置上位图快照(相当于对当前窗口进行截图),并且设置为 VISIBLE,然后对 次根 View (drawerLayoutContainer)施加动画效果(由 0 到 finalRadius 逐渐扩散)并切换到夜间模式。
切换到日间前,ImageView 放在根 View 顶层,设置上位图快照,并且设置为 VISIBLE,然后直接对它施加动画效果(由 finalRadius 到 0 逐渐缩小)并切换到日间模式。

其实不只是我,许多人看到了这种精妙的解决方式都觉得妙不可言。竟然还有这种实现方式的?

那我们应该怎么样才能把这种炫酷的环形揭示切换动画提取出来,给我们自己的 App 使用,或者做成一个库呢?我们可以看到,确实有一些厂商也使用了这种效果,比如酷安。

/   其他人的实现方式   /

我从 GitHub 上找到两款比较优质的库/实现(似乎做这种库的人比较少,stars 数都不多):

MaskAnim

[MaskAnim] 的作者也在掘金上写过类似的文章,指路 [安卓开发实现电报的主题切换动画效果]。原文地址:
https://juejin.cn/post/7300715626817224715

它的思路是:自定义 MaskView,在 onDraw() 方法里手动实现此 View 的扩散和收缩流程。将 MaskView 动态添加于 DecorView,根据传入的 MaskAnimModel 是 EXPAND 还是 SHRINK 来判断 MaskView 扩散还是收缩,动画结束之后,将 MaskView 移除于 DecorView。

所以可以看到 MaskView 是永远处于顶层的,而且没有使用 ImageView,跟 Telegram 的实现思路还有点不同。这种方法其实不错,拓展性也高(如果你想要其他动画),而且支持 Compose!但是如果对自定义 View 不熟悉的话,实现 MaskView 会有些难度。

CircularRevealThemesChanger

[CircularRevealThemesChanger] 是根据这篇文章写的。原文地址:
https://pspdfkit.com/blog/2020/change-android-themes-with-circular-reveal-animation/

它的思路是:将屏幕截图传给另一个 Activity 处理,另一个 Activity 用和 Telegram 一样的 ViewAnimationUtils#createCircularReveal 方法处理完动画之后再 finish()。当然,在启动 Activity 和 finish Activity 的过程中添加了取消启动动画的 Flag。

这种方式确实有点拉了,为实现这么一个效果引入一个新 Activity 不太可取。

其他

还有的人是往自己 xml 里的根 ViewGroup 里定义一个 ImageView 然后实现动画,还有的人是直接通过 LayoutInflater.Factory2 直接将 xml 的 View 映射为自己定义的“傀儡” View。

实现方式属实有点多,但到底有没有稍微简单点的方式,比如:

view.setDayNightModeSwitcher()

一句代码就能实现几乎所有功能呢?

其实 MaskAnim 也基本上实现了一句代码实现功能,但我从 Telegram 实现角度上重新组织一下。

/   TG 方向实现思路   /

我们先捋一下我们需要哪些函数才能实现这个功能:

判断当前是夜间还是日间模式:

AppCompatDelegate.getDefaultNightMode()

切换夜间或日间模式:

AppCompatDelegate.setDefaultNightMode(
    AppCompatDelegate.MODE_NIGHT_YES // or AppCompatDelegate.MODE_NIGHT_NO
)

对当前整个屏幕进行截图:

protected open fun Window.screenshot() = decorView.rootView.drawToBitmap()

...

第二个和第三个是比较重要的,因为我们在做切换动画的时候就指望这两个函数起作用。

⚠ 注意:Telegram 的实现方式并不是通过 recreate,具体可以参考源码实现。recreate 实现更直观。

收缩

假设收缩是切换至日间模式。按照普通思路,我们可以模拟出这样的伪代码:

screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToDayMode() // 切换至日间模式
RootView.addView(ImageView) // 给根 View 添加最前端全屏 ImageView
ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
ImageView.animate() // ImageView 进行动画,动画半径由最大 radius 到 0,结束

这种思路是完全没有错的,但是有一个地方很容易被忽略,设置成夜间模式有一个 recreate 的过程,让我们观察 AppCompatDelegate#setDefaultNightMode 方法调用栈:

AppCompatDelegate#setDefaultNightMode

AppCompatDelegate#applyDayNightToActiveDelegates

AppCompatDelegateImpl#applyDayNight

AppCompatDelegateImpl#applyApplicationSpecificConfig

AppCompatDelegateImpl#updateAppConfiguration

ActivityCompat#recreate

在稍微有点深的嵌套中,最后调用了熟悉的 ActivityCompat#recreate 方法。那这个方法又干了什么呢,看看调用栈:

ActivityCompat#recreate

Activity#recreate

ActivityThread#scheduleRelaunchActivity

ActivityThread#scheduleRelaunchActivityIfPossible

ActivityThread#sendMessage

ActivityThread.H#sendMessage

Handler#sendMessage

可以看到,recreate 就是给 ActivityThread 的 内部类 H 发送了一个异步消息,要求执行 recreate 作业。通过观察得知,H 类其实就是特殊的 Handler(Looper.getMainLooper()),因为 ActivityThread 所处的线程就是主线程。

Handler 的工作原理如下:

  1. 使用 Handler 的 sendMessage() 或 post() 方法,会将消息添加到 MessageQueue 中。
  2. Looper 不断从 MessageQueue 中检索消息。
  3. 当 Looper 检索到消息时,它会将其传递给相应的 Handler。
  4. Handler 的 handleMessage() 方法会处理消息。

众所周知,MessageQueue 是一个先进先出(FIFO)的队列,这确保如果向同一个 Handler 发送多个消息,则它们将按发送顺序处理。

这给了我们很好的思路,既然 recreate 发送了消息,假设该消息占用了消息队列的第一位,那我们占第一位的后面不就好了?而且,recreate 发送的是异步(Asynchronous)消息,不受同步屏障影响,所以基本上 recreate 是立即完成的,几乎不用考虑时间问题,我们可以在 recreate 后的新的 Activity 里执行动画。

让我们写一下新的伪代码:

// 此时 Activity 为 旧 Activity(夜间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToDayMode() // 切换至日间模式
MainHandler.post { // 将下面的消息传递在 recreate 后面
    // 此时 Activity 为 新 Activity(日间模式)
    RootView.addView(ImageView) // 给根 View 添加最前端全屏 ImageView
    ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
    ImageView.animate() // ImageView 进行动画,动画半径由最大 radius 到 0,结束
}

这样就又完善了一步。但现在的问题是:RootView 到底选择哪个?我们需要在动画结束之后将 ImageView 移除,但此时 Activity 已经是新的了,里面的字段基本都被重建了,应该怎么办?

让我们再看看发送的 recreate 事件会在 handleMessage() 方法中执行什么样的调用栈:

ActivityThread#handleRelaunchActivityLocally

ClientTransactionHandler#executeTransaction

...

TransactionExecutor#performLifecycleSequence

ActivityThread#handleRelaunchActivity

ActivityThread#handleRelaunchActivityInner

ActivityThread#performPauseActivity #1

ActivityThread#callActivityOnStop #2

ActivityThread#handleDestroyActivity #3
    ↓
    ActivityThread#performDestroyActivity

ActivityThread#handleLaunchActivity

ActivityThread#performLaunchActivity
    ↓                ↓
    Activity#attach  Activity#setTheme #5

Instrumentation#callActivityOnCreate

Activity#performCreate #4

Activity#onCreate

很标准的 Pause → Stop → Destroy → Create → (Start → Resume)

这就是 handler#post 之后走向新 Activity 的原因,而且会走完 onResume() 方法。

看向 #5,这也是为什么 setTheme 必须要在 super.onCreate 前执行。

我们发现,Activity 重建后,不论是 context 还是 window,全部都被重建了(参考 Activity#attach),唯一坚挺的就是 DecorView。

为什么这么说呢?在相关源码中明明 mWindow 都重新赋值了,DecorView 有什么理由不跟着变化呢?让我们看看 PhoneWindow 的构造器:

public PhoneWindow(Context context) {
    super(context);
    mLayoutInflater = LayoutInflater.from(context);
}
/**
 * Constructor for main window of an activity.
 */
public PhoneWindow(Context context, Window preservedWindow,
ActivityConfigCallback activityConfigCallback) {
    this(context);
    // Only main activity windows use decor context, all the other windows depend on whatever
    // context that was given to them.
    mUseDecorContext = true;
    if (preservedWindow != null) {
        mDecor = (DecorView) preservedWindow.getDecorView();
        mElevation = preservedWindow.getElevation();
        mLoadElevation = false;
        mForceDecorInstall = true;
        // If we're preserving window, carry over the app token from the preserved
        // window, as we'll be skipping the addView in handleResumeActivity(), and
        // the token will not be updated as for a new window.
        getAttributes().token = preservedWindow.getAttributes().token;
    }
    // Even though the device doesn't support picture-in-picture mode,
    // an user can force using it through developer options.
    boolean forceResizable = Settings.Global.getInt(context.getContentResolver(),
    DEVELOPMENT_FORCE_RESIZABLE_ACTIVITIES, 0) != 0;
    mSupportsPictureInPicture = forceResizable || context.getPackageManager().hasSystemFeature(
        PackageManager.FEATURE_PICTURE_IN_PICTURE);
    mActivityConfigCallback = activityConfigCallback;
}

Activity 中,走的是第二个构造器,可以看到 DecorView 是重复利用的。

⚠ 但请注意:这是 Android 7.0 及以上的源码,在 API 23 及之前,DecorView 是不重复利用的,PhoneWindow 只有第一个构造器!所以你在 API 23 及之前切换主题或者切换夜间模式的时候,会黑一下屏!所以我暂定把最低支持 API 设置为 24。

所以,我们就可以名正言顺的在 DecorView 中添加 ImageView 和删除 ImageView 了!而且 DecorView 位于当前 Activity 的最顶层,非常方便地就能实现全屏动画!


所以,我们又可以更新我们的伪代码:

// 此时 Activity 为 旧 Activity(日间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToNightMode() // 切换至夜间模式,内部是 MainHandler
MainHandler.post { // 将下面的消息传递在 recreate 后面
    // 此时 Activity 为 新 Activity(夜间模式)
    // 几乎所有之前的字段都不能调用,比如 Window,Activity,因为重建了
    // 非要调用最后也没效果
    DecorView.addView(ImageView) // 给 DecorView 添加最前端全屏 ImageView
    ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
    ImageView.animate(onEnd = {
        DecorView.removeView(ImageView)
    }) // ImageView 进行动画,动画半径由最大 radius 到 0,结束后删除 ImageView
}

一个收缩动画就结束了。


扩张

假设扩张是切换至夜间模式。

扩张与收缩其实是差不多的,要注意我们需要扩张的是 DecorView 中的 content 界面,可以通过 findViewById() 寻找这个 次根 View。

// 此时 Activity 为 旧 Activity(日间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToNightMode() // 切换至夜间模式,内部是 MainHandler
MainHandler.post { // 将下面的消息传递在 recreate 后面
    // 此时 Activity 为 新 Activity(夜间模式)
    // 几乎所有之前的字段都不能调用,比如 Window,Activity,因为重建了
    // 非要调用最后也没效果
    DecorView.addView(ImageView, 0) // 给 DecorView 添加最末端全屏 ImageView
    ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
    content = DecorView.findViewById(android.R.id.content) // 次根 View
    content.animate(onEnd = {
        DecorView.removeView(ImageView)
    }) // content 进行动画,动画半径由 0 到最大 radius,结束后删除 ImageView
}


效果和 Telegram 是一模一样的,如果感兴趣,你可以调慢动画速度,然后在 Telegram 切换主题时滑动一下窗口试试,但内部实现方法还是有点差异。

通过这思路,你不仅可以实现日间夜间的切换,也可以实现主题的切换,只需要把这些相关代码包裹在 view 的 setOnClickListener() 和 setOnTouchListener() 里就可以了!

定位

我们更希望在点击的位置进行环形的扩张与收缩,这时候就需要当前点击位置的 XY 值。

这个就比较常见了:

override fun onTouch(v: View, event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
            // Do not use event.rawX and event.rawY
            // It is not accurate in floating window mode
            // x = event.rawX
            // y = event.rawY
            v.getLocationInWindow(locationInWindow)
            x = event.x + locationInWindow[0]
            y = event.y + locationInWindow[1]
            if (DEBUG) {
                Log.d(TAG, "onTouch: x = $x, y = $y")
            }
        }
    }
    return false
}

正如注释所说,不要去直接使用 rawX 和 rawY。这两个字段在全屏模式确实是准确的,但悬浮窗模式就不准了。

Logcat 观察

Activity#recreate 后执行 Handler#post 直接到达 onResume():


DecorView 在 Activity 重建过后依然保持内存地址不变,而 Activity 和 Window 发生了变化:


/   整理成库   /

我已经将这些整理成了一个 CircularRevealSwitch 库,欢迎指点!

使用也很简单,实现最基础的功能,只需要:


关键代码在这里:
https://github.com/YenalyLiew/CircularRevealSwitch/blob/master/circularrevealswitch/src/main/java/com/yenaly/circularrevealswitch/CircularRevealSwitch.kt

/   目前存在的问题   /

使用 HarmonyOS 和 HyperOS(MIUI) 动画一切正常,经过观察,DecorView 是一直无变化的,符合预期。但是使用 Android Emulator 测试的时候,不知道为什么 DecorView 一直在变化,导致动画失效。

部分手机 DecorView 显示的速度会比后添加的 ImageView 快,导致闪屏问题(比如 HarmonyOS),我现在也没什么好办法解决,若有知道的可以评论区指点一下,感激不尽!

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
Linux网络编程:TCP与UDP详解
都说Retrofit好,好在哪里?只有动态代理么?

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注
继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存